Type Deduction - Template Type Deduction

从 C++98 引入模板机制开始,C++ 终于具备了对泛型编程范式的支持,其中函数模板的类型推断也成为 C++ 首个实现的类型推断机制。自 C++11 (C++14) 后,类型推断体系又进一步扩展,新增两名大将——autodecltype。后来的版本中,这些特性又被不断扩展完善。

这些范型机制给程序员提供了便利,同时也显著提升了 C++ 程序的适应性。由于编译器会根据类型帮你生成相应的代码,所以你不需要在某一处源码修改后到处改改改。为了能够利用这些工具编写高效的代码,你需要了解编译器帮你类型推导时的行为。

Template Type Deduction Basics

作为 C++ 类型推断的鼻祖,学习模板类型推断对于后面深入理解 auto 至关重要,在学习 autodecltype 之前,了解了解模板类型推断还是有意义的。这里,我们不深究类型推断到底是怎么实现的,而是讨论一下语言提供的接口究竟是怎么样的。

在使用函数模板时:

template<typename T>
void func(ParamType param);

// A function call
func(expr);

这里的 T 是模板参数类型,ParamType 是该模板参数所构成的函数参数类型,而 expr 用于调用函数的实际表达式。编译器会根据 expr 的类型来推导出实际 T 的类型,而 ParamType 往往由 T 直接构造。我们举个例子:

template<typename T>
void func(const T& param); // ParamType is const T&

func(3.14); // (1)
const int& i = 30;
func(i); // (2)

上面的例子中,(1) 中,由于 expr 是一个 double 类型的字面量,所以 T 被推导为 double;而在 (2) 中,i(也就是 expr)的类型是 const int&,然而编译器最终推导出的 Tconst int,引用类型 & 由函数参数定义决定。

划分 TParamType 的行为可能会反直觉,(2) 中的推导也让人迷糊,我们可能期望编译器帮我们推断的 Texpr 的类型严格一致,但实际上,这些想法对于接口设计而言并不友好。C++ 的模板推导可能弱化了对某些修饰符的保留,但接口实实在在地灵活通用了。所以尽管可能并不符合直觉,但的的确确是提高范型编程质量的关键。下面,我们将详细地解释其中类型推断的秘密。

如果确实对于严格一致的类型推导有要求,C++14 提供了直接好用的工具:decltype(auto)。详见:Type Deduction - Decltype in C++

Three Types of ParamType

Scott 从 ParamType 将函数类型推断分为三个 cases:

  • ParamType 是指针或引用类型,但不是万能引用;
  • ParamType 是万能引用;
  • ParamType 既不是是指针也不是引用类型。

Case 1: ParamType is a Reference or Pointer

这是最常用的情况,在这个 case 下,类型 T 是这样推得的:

  1. 如果 expr 是引用类型,忽略引用的部分,如果是一个指针,忽略掉小星号;
  2. 将实参 expr 与形参 ParamType 进行模式匹配,找到不相交的部分作为 T 的类型修饰。

这两点实际上就解除了我们前面的疑惑。假如我们有这样的例子:

template<typename T>
void func(T& param); // ParamType is T&

func(3.14); // (1)
const int& ri = 30;
func(i); // (2)

(1) 的推导结果仍然相同,然而不同于前面 (2) 的推导,由于这里 ParamTypeT& 类型,所以 i 忽略引用的其他修饰符会得到保留,因为 ParamType 没有 const 的修饰,所以 T 的类型是 const int

如果 ParamType 是一个指针类型,实际的情况和引用差不多,只不过参数变成了指针,举个例子:

template<typename T>
void func(T* param); // ParamType is T*

int i = 10;
func(&i); // (1) - which T is deduced to int
const int* pi = i;
func(i); // (2) - which T is deduced to const int

Case 2: ParamType is a Universal Reference

如果 ParamType 是一个万能引用,那就可能没有上面的看上去那么简单直白。由于引用折叠 (referencr collapse) 的原因,使得 T&& 的范型引用可以既接收左值又接收右值,让编译器在收到左值和右值对象时生成对应的函数代码。我们先看看什么是引用折叠:

Declared Type of ParamType Deduced T Collapsed ParamType
T& T T&
T& T& T&
T& T&& T&
T&& T T&&
T&& T& T&
T&& T&& T&&

因为 ParamType 是一个右值引用(如 T&&),在模板中使用该参数时,由于引用折叠规则,当实参 expr 是左值时,编译器就会将 T 推导成左值引用,进而,ParamType 就会被折叠成左值引用类型;而实参是右值时,T 就会被推导为非引用的 fundamental types,最终 ParamType 会被推导成右值引用类型。这种机制使得 ParamType 可以完美地保留 expr 的引用性。

理解了上面的引用折叠,我们接着学习在第二个 case 中的类型推导,其过程如下:

  1. 如果 expr 是左值,TParamType 最终都会被推导成左值引用;
  2. 如果 expr 是右值,就遵循 case 1 的规则。

举个例子:

template<typename T>
void func(T&& param); // param is now a universal ref

int i = 10;
const int ci = i;
const int& ri = i;

func(i); // (1) - x is lvalue, thus T is int&, param's type too
func(ci); // (2) - ci is lvalue, so T is const int&, param's type too
func(ri); // (3) - ri is lvalue, so T is const int&, param's type too
func(10); // (4) - 10 is rvalue, so T is int, param's type is int&&

Case 3: ParamType is Passed-by-Value

如果模板函数的参数是值传递,也就是说,无论 expr 是什么,在 func(T param) 栈帧构造的时候,都会在栈帧中拷贝一份全新副本。即:

template<typename T>
void func(T param);

这时,类型推导的规则如下:

  1. 和 Case 1 一样,如果 expr 是一个引用类型,即忽略引用;
  2. 如果 expr 被 CV-qualifier 所修饰,即忽略这些修饰;

最终,无论实参 expr 是什么,编译器所推得的 T 都会是原始类型,比如:

template<typename T>
void func(T param);

int i = 10;
const int ci = i;
const int& ri = i;

func(i); // (1)
func(ci); // (2)
func(ri); // (3)
func(10); // (4)

无论是 (1), (2), (3) 还是 (4),TParamType 的类型都会是 int。由于是副本,所以推得类型并不带有任何的 specifier,因为不会影响原本的对象。

这里,指向常量的常指针的按值传参需要注意一下,在例子:

template<typename T>
void func(T param);

const char* const ptr = "Hello, world!"
func(prt);

中,我们有一个指向常量的常性指针,也就是说,指针指向的区域不允许修改,同时这个指针本身也不允许修改。那么,出现了两个 const 按值传参时,是否需要将这两个 const 都忽略呢?答案是否定的,既然我们知道传进去的是指针的副本,所以对指针的常性是可以忽略的。然而,数据的常性是不可以省去的,不然就破坏了数据的只读。

所以最终推导的 TParamType 都将是 const char* 保留对数据的常性。

Besides...

除了以上,还有两类比较特别的情况——数组参数和函数参数。

Array Arguments

虽然数组和指针的行为类似,而且有时候还能转换(数组退化),但数组和指针还是有区分的。比方说,数组会保存大小信息,而指针不会、数组的指向不可以修改,而指针可以修改指向。

const char greeting[] = "Hello, world!"; // greeting is a const char[14]

const char* prt = greeting; // Array decays to ptr

既然不一样,而且按值传参时,数组会退化成指针。那么在下面这种情况发生时,T 会被推导成什么类型呢?

template<typename T>
void func(T param);

func(greeting);

虽然语法上你可以将参数写成 void func(int param[]) 但行为上,编译器会将其退化为 void func(int* param)。所以从语言层面来看,并不存在数组的函数参数。在上面的类型推导中,由于传入的是字符串常量,而且传入了一个数组参数和编译器的退化行为,最终推得 T 的类型是 const char*

为了保留数组的类型信息,我们可以按引用传入数组,避免退化,比如:

template<typename T>
void func(T& param);

func(greeting); // pass array to func by ref

这时编译器推得 T 的类型就是 const char [14] 了,param 的类型将是 const char (&)[14]。由于数组类型会保留数组的元素个数,所以你还可以让编译器帮你获取数组元素个数,由于这一切发生在编译期,所以你可以用 constexpr 进行优化:

template<template T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept {
	return N
}

Function Arguments

除了数组的指针退化外,函数类型也可能会退化成函数指针。同样的,按值传入函数对象和按引用传入函数对象看上去也不太一样。但和数组的指针退化不同的,函数的函数名本身就是指向一段代码区域的指针,本身并不额外保留任何信息,所以按值和按引用传入函数对象除了类型不太一样以外没什么区别(他们的用法都是一样的)。

void func(int, double); // function name

template<typename T>
void passByValue(T param) { // T --> void(*)(int, double)
	param(1, 3.14);
}
template<typename T>
void passByRef(T& param) { // T --> void(&)(int, double)
	param(1, 3.14); // the usage's the same
}

passByValue(func);
passByRef(func);